iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Build on Google AI

DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統系列 第 20 篇

[Day 19] 第一版 Alpha 網頁上線:簡單 UI 接上 Gemini 和 Firestore,Cache 會留下舊過敏嗎?

  • 分享至 

  • xImage
  •  

Part 4|第 20/30 篇
今日要做的事: 用一個簡單網頁把六步測試、Gemini 和 Firestore 接在同一頁。一次晚餐請求分成規則短路、exact response cache、真的打 Gemini,並把 token 和延遲寫回 Firestore。
今天要解決的目的: 同一個問題問三次,不該付三次錢、等三次;但 LifeFlow 的過敏一改,舊答案必須當場失效。

Day 16 之後,每次問晚餐都要送規則、食譜、策略、冰箱、今天吃過什麼。連按三次「今晚吃什麼」,就是同一份 context 送三次。

想省錢最直覺的做法是「同一個人問同一題,就回上次的答案」。上午這樣做沒問題;下午我發現自己對花枝過敏,晚上它還是端出花枝丸豆腐煮。

https://ithelp.ithome.com.tw/upload/images/20261004/20121052bziPQRdcvJ.jpg


今日任務卡

項目 內容
產出 一個本機網頁、三條請求路徑、版本化的 response key、每次請求的成本紀錄
Google 服務 Firebase Auth 登入、Firestore(LifeFlow 快照、response_cache、requests、transaction)、Gemini API generateContent 與 usageMetadata
模型 前半實測 gemini-3.8-flash;免費方案 20 次用完後,預設改為 gemini-3.5-flash,不再跳回 3.8
沿用 Day 7 的 Firestore rules、Day 12 validate、Day 16 compileAuthoritative、Day 17 decide
不做 語意相似 cache、用字數估 token、把金額寫死在程式裡
指標 input / cached / output / thought tokens、模型延遲、總延遲、response_cache_hit、model_calls

這篇不附整包程式下載。第 3 節列出資料夾結構、Firestore 結構和每個檔案的核心段落,照著組就能重現:node test.js 鎖住 key 與短路的行為,npm run dev 開網頁,登入後照左邊六顆按鈕走一遍。


1. 問題:成本優化最怕把錯的結果加速

今天的 LifeFlow 快照和 Day 16 一樣,只多一包快過期的花枝丸:

過敏:crustacean(甲殼類)
冰箱:豆腐 0.5 盒(快過期)、雞蛋 4 顆、花枝丸 6 顆(快過期)
爐口:1
食譜庫:豆腐蛋炒、豆腐湯配煎蛋同時煮、花枝丸豆腐煮

這時花枝丸豆腐煮是合格的。要是 cache key 只用「uid + 今晚吃什麼」:

18:00  問晚餐 → Gemini 推薦花枝丸豆腐煮 → 存起來
18:30  在 LifeFlow 新增 mollusk(軟體動物)過敏
19:00  再問一次 → key 一樣 → 端出 18:00 的花枝丸豆腐煮

所以今天要回答的不是「能不能 cache」,而是:

哪些資料可以重用,哪些變更必須讓舊推薦當場作廢?

另一件事更簡單:規則已經能回答的請求,本來就不該送 Gemini。沒有送出的 token 最便宜。


2. 設計:一次請求的三條路

https://ithelp.ithome.com.tw/upload/images/20261004/20121052OIDuNtwsNF.png

路一 SHORT_CIRCUIT。 compile 先跑。晚餐改外食只是「記一筆」,寫進 today_meals 就結束,不碰 pantry,也不叫模型。缺預算或可用時間時,先問使用者,同樣不叫模型。

路二 CACHE_HIT。 要推薦時,先算 response_key,到 Firestore 的 response_cache 找。完全一樣才算命中,不做「這兩個冰箱看起來差不多」的語意相似。

路三 GEMINI。 沒命中才打 Gemini,回來先過伺服器的 decide,只有通過 validate 的方案寫回 cache。被擋下的結果不進 cache,下次不會被重用。

三條路的每一次請求,都在 Firestore 的 requests 寫一筆成本紀錄。沒打模型的那幾筆,token 欄位寫 not_applicable,不寫 0。0 是「量到了,是零」;not_applicable 是「這次根本沒有可量的東西」。


3. 實作

3.0 先把骨架排好

day19/
├─ index.html          六顆按鈕、成本紀錄表、推薦結果
├─ package.json        firebase、vite
├─ vite.config.js      port 3019,允許讀上一層沿用的模組
├─ .env                Firebase 設定與 Gemini 金鑰,不進版控
├─ src/
│  ├─ firebase.js      initializeApp、Auth、Firestore
│  ├─ core.js          食譜、穩定前綴、responseKey、shortCircuit
│  ├─ gemini.js        generateContent、忙碌時重試、整理 usageMetadata
│  └─ main.js          三條路、transaction、寫 requests
├─ probe.js            探顯式 Context Cache 的門檻與額度
├─ implicit.js         同一份快照連打三次,看隱式快取
└─ test.js             六個本機測試

驗證邏輯不重寫,直接 import 前幾天的模組:Day 11 的策略目錄 catalog.js、Day 12 的 validate.js 和 allergen-map.js、Day 16 的 compileAuthoritative、Day 17 的 decide。這幾支都是純 JavaScript,沒有用到 Node 專屬 API,瀏覽器也跑得動。Vite 預設不讓網頁讀專案根目錄外的檔案,要打開一層:

export default defineConfig({
  server: { port: 3019, fs: { allow: [".."] } },
});

.env 只放這七個變數,值不貼進文章:

VITE_FIREBASE_API_KEY
VITE_FIREBASE_AUTH_DOMAIN
VITE_FIREBASE_PROJECT_ID
VITE_FIREBASE_STORAGE_BUCKET
VITE_FIREBASE_MESSAGING_SENDER_ID
VITE_FIREBASE_APP_ID
VITE_GEMINI_API_KEY

Firestore 全部放在登入者自己的 users/{uid} 底下,Day 7 那條「只有本人能讀寫整棵子樹」的 rules 不用改:

users/{uid}/
├─ day19_state/hard_profile   hard_profile_version、allergens、kitchen
├─ day19_state/pantry         pantry_version、items
├─ day19_state/today          today_meals、budget_twd、available_time_minutes
├─ response_cache/{rk_…}      key_parts、options、blocked、naive_key
└─ requests/{自動 id}          path、版本、四種 token、延遲

3.1 key 裡放版本,不放「是誰」

export async function responseKey({ model, hard, pantry, today, strategyIds }) {
  const parts = {
    model,
    prompt_schema_version: PROMPT_SCHEMA_VERSION,
    verifier_version: VERIFIER_VERSION,
    hard_profile_version: hard.hard_profile_version,
    pantry_version: pantry.pantry_version,
    today_hash: (await sha256Hex(canonical(today))).slice(0, 12),
    strategy_ids: [...strategyIds].sort(),
  };
  return { key: `rk_${(await sha256Hex(canonical(parts))).slice(0, 24)}`, parts };
}

uid 不在 key 裡,因為 cache 本來就放在 users/{uid}/response_cache 底下,Day 7 的 rules 只讓本人讀寫,別人的 key 撞到也讀不到。

canonical 會先把欄位排序再算 hash。同樣的 today,budget_twd 寫在前面或後面,不該變成兩份 cache。hash 用瀏覽器和 Node 都有的 Web Crypto,同一支函式網頁和測試都能用:

export function canonical(value) {
  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
  if (value && typeof value === "object") {
    return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
  }
  return JSON.stringify(value);
}

export async function sha256Hex(text) {
  const bytes = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
  return [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, "0")).join("");
}

verifier_version 也在裡面。哪天 Day 12 的過敏對照表多收一個同義詞,舊 cache 是用舊規則驗過的,一樣要作廢。

3.2 過敏一改,用 transaction 升版

await runTransaction(db, async (tx) => {
  const ref = userDoc("day19_state", "hard_profile");
  const current = (await tx.get(ref)).data();
  const allergens = [...new Set([...current.hard_constraints.allergens, "mollusk"])];
  tx.update(ref, {
    "hard_constraints.allergens": allergens,
    hard_profile_version: current.hard_profile_version + 1,
    updated_at: serverTimestamp(),
  });
});

改過敏和升版本必須同時成功。如果過敏寫進去了、版本沒加,下一次請求算出來的 key 跟舊的一樣,就是開頭那個 bug。

這裡不靠 TTL。就算舊 cache 還有一小時才過期,版本不同就是 miss。舊的那份也不用去刪,它的 key 再也不會被算出來。

3.3 一次請求怎麼走

短路的條件只看規則已經知道的事:

export function shortCircuit({ task, today }) {
  if (task === "record_meal" && today.food_source === "eat_out") {
    return { code: "EAT_OUT_RECORD", message: "外食紀錄:寫 today_meals,不寫 pantry,不叫模型" };
  }
  if (task === "recommend" && (today.budget_twd == null || today.available_time_minutes == null)) {
    return { code: "INVALID_CONTEXT", message: "缺預算或可用時間,先問使用者" };
  }
  return null;
}

三條路接在一起:

const stop = shortCircuit({ task, today });
if (stop) return finish(row, t0, { path: "SHORT_CIRCUIT", model_calls: 0 });

const { key, parts } = await responseKey({ model: DEFAULT_MODEL, ...life, today });
const cached = await getDoc(userDoc("response_cache", key));
if (cached.exists()) return finish(row, t0, { path: "CACHE_HIT", model_calls: 0, ...cached.data() });

const ai = await askGemini(apiKey, buildVariablePart({ ...life, today }));
const verdict = decide({ requestId, profile, today, modelOptions });
if (verdict.options.length > 0) {
  await setDoc(userDoc("response_cache", key), { key_parts: parts, options: verdict.options, ... });
}
return finish(row, t0, { path: "GEMINI", model_calls: 1, ...ai.usage });

finish 把 path、key、兩個版本、四種 token、模型延遲、總延遲一起寫進 requests。總延遲從按下按鈕算到結果出來,包含 Firestore 讀寫,不只算模型那一段。沒打模型的路,欄位補成 not_applicable:

for (const k of ["input_tokens", "cached_input_tokens", "output_tokens", "thought_tokens", "model_latency_ms"]) {
  if (record[k] === undefined) record[k] = "not_applicable";
}
await addDoc(userCol("requests"), record);

3.4 穩定的放前面,會變的放後面

contents: [{ role: "user", parts: [{ text: STABLE_PREFIX }, { text: variablePart }] }],

STABLE_PREFIX 是每次都一樣的東西:規則、三道食譜、策略目錄、過敏同義詞。冰箱、過敏、今天吃過什麼放在後面。Gemini 的 Prompt Caching 只能重用「開頭一模一樣」的部分,順序放反,什麼都重用不到。

回來之後,token 一律從 usageMetadata 拿,不自己估。cachedContentTokenCount 沒出現就是 0,代表這次真的沒命中:

const u = body.usageMetadata ?? {};
return {
  model: body.modelVersion ?? model,
  parsed: JSON.parse(text),
  usage: {
    input_tokens: u.promptTokenCount ?? null,
    cached_input_tokens: u.cachedContentTokenCount ?? 0,
    output_tokens: u.candidatesTokenCount ?? null,
    thought_tokens: u.thoughtsTokenCount ?? 0,
    total_tokens: u.totalTokenCount ?? null,
  },
  model_latency_ms: Math.round(latency),
};

網頁直接從瀏覽器打 Gemini,金鑰放在 VITE_GEMINI_API_KEY。VITE_ 開頭的變數會被打包進前端,所以這頁只在 localhost 跑;要上線,這段得搬到伺服器端。


4. 先驗:key 和短路有沒有照設計走

https://ithelp.ithome.com.tw/upload/images/20261004/20121052Sv1OrEj7TH.png

六個測試鎖住四件事:同一份快照 key 不變、欄位順序不影響 key、pantry 或 hard profile 換版 key 一定換、外食紀錄直接短路。最後一個是 Day 12 的安全網:加了 mollusk 之後,就算模型說花枝丸豆腐煮 hard_constraints_ok: true,decide 一樣擋下。


5. Prompt Caching 實測:沒有我想的那麼好用

Day 19 原本的計畫是用 Gemini 的 Context Cache,把穩定前綴存起來,後面每次只付變動那段的錢。下面這兩次翻車,都是還在用 gemini-3.8-flash、專案還在免費方案時打的。

第一次:太短。 前綴一開始只有規則和食譜:

stable prefix tokens 844
Cached content is too small. total_token_count=844, min_total_token_count=1024

顯式快取最少要 1024 token。我回頭看 Day 19 草稿,策略目錄和過敏同義詞本來就該放在穩定前綴,只是我沒放進去。補進去之後變成 1809。

第二次:免費方案沒有額度。

https://ithelp.ithome.com.tw/upload/images/20261004/20121052AzcGgHh9Cx.jpg

TotalCachedContentStorageTokensPerModelFreeTier limit exceeded for model gemini-3.8-flash: limit=0

換成 gemini-3.5-flash 也一樣是 limit=0。顯式 Context Cache 在免費方案用不了。

退一步:隱式快取。 不用建 cache,只要前綴一樣、夠長,Gemini 會自己重用,命中時 usageMetadata.cachedContentTokenCount 會大於 0。我用同一份快照連打三次:

https://ithelp.ithome.com.tw/upload/images/20261004/20121052ekf1Cu146K.jpg

次數 模型 input cached output thought 模型延遲
1 gemini-3.8-flash 2016 0 202 1407 7732 ms
2 gemini-3.8-flash 2016 0 213 1392 7247 ms
3 gemini-3.8-flash 2016 0 201 755 7105 ms

三次都有「模型忙碌」重試,但最後都還是 gemini-3.8-flash 回來,cached 都是 0。隱式快取是「有機會」,不是保證,這次一次都沒輪到。

這張表還藏著第二件事:thought token 是 output 的 4 到 7 倍(1407、1392、755)。就算隱式快取哪天命中,折扣的也只是 input;這三段思考照付。對 DishFlow 來說,Prompt Caching 不是今天的主角。

網頁上的第一次請求 input 是 2046,比這裡多 30。主要差在 Firestore 讀回來的 hard profile 和 pantry 多了 updated_at,字串變長,token 就跟著變。cache key 只看版本,不看這個時間戳,所以不影響命中。


6. 接上 Firebase:真正把一整次呼叫省掉的是 response cache

gemini-3.8-flash 先問了兩次。第一次 key rk_a22b07ca 走 GEMINI,input 2046、cached 0、output 278、thought 1298,模型 6594 ms,含重試的總時間 49453 ms。第二次同一個 key 走 CACHE_HIT,1285 ms,token 全是 —。第三次要把花枝丸扣 3 顆,transaction 先成功,接著 cache miss,API 拒絕:

Quota exceeded for metric:
generativelanguage.googleapis.com/generate_content_free_tier_requests,
limit: 20, model: gemini-3.8-flash
Please retry in 10h2m51s

免費方案這個模型每天 20 次 generateContent。沒有綁帳單,就會停在這個 limit。今天 probe.js、implicit.js 和網頁都打過 3.8,中間還有好幾次「模型忙碌」的重試;CACHE_HIT 不打模型,這次 miss 才撞上。配額錯誤不算忙碌,程式沒有再重試。這筆沒寫進 requests,冰箱卻已經扣過。

要繼續測,錢是在下一張畫面付出去的。預付 NT$150,餘額低於 150 會再自動加 150。月上限當時是 No limit set。文章截圖前我把帳號編號和卡號遮掉。

https://ithelp.ithome.com.tw/upload/images/20261004/20121052rXT2ido8JM.png

綁帳單後,用 gemini-3.5-flash 重跑六步

3.8 今天常常 high demand,每次都要等重試。綁完之後預設改成 gemini-3.5-flash,忙碌時也不再跳回 3.8。key 裡有模型名稱,所以從第 1 步把快照和 cache 清掉,六步重走。下面這張是 gemini-3.5-flash 版 走完六步後的畫面,五筆請求都寫進 requests:

https://ithelp.ithome.com.tw/upload/images/20261004/201210527XC8kB2UTn.jpg

# 情境 路徑 hard v pantry v input output thought 總延遲
1 第一次問 GEMINI 1 1 2046 192 1165 7561 ms
2 再問一次 CACHE_HIT 1 1 — — — 358 ms
3 花枝丸少 3 顆 GEMINI 1 2 2047 197 1046 7145 ms
4 新增 mollusk 過敏 GEMINI 2 2 2051 283 1558 8594 ms
5 晚餐外食 SHORT_CIRCUIT 2 2 — — — 482 ms

三次 GEMINI 的 cached 都是 0。圖左下是走完之後 Firestore 上的 LifeFlow:hard v2,過敏變成 crustacean, mollusk;pantry v2,花枝丸剩 3 顆;今天已吃多了 dinner:eat_out。右下顯示的是最後一步外食的結果,model_calls = 0。

逐步看:

  1. 寫入初始快照:hard v1、pantry v1。過敏是 crustacean,花枝丸 6 顆。
  2. 第一次問:key rk_279d3e6d,走 GEMINI。input 2046、cached 0、output 192、thought 1165。模型 6604 ms,總共 7561 ms。豆腐蛋炒、花枝丸豆腐煮上桌,兩爐的湯被 BURNER_CONCURRENCY_EXCEEDED 擋下。
  3. 再問一次:同一個 key,走 CACHE_HIT,358 ms。token 和模型延遲都是 —。
  4. 花枝丸少 3 顆:pantry 升到 v2,key 換成 rk_2379137d,再走 GEMINI。input 2047、output 197、thought 1046,總共 7145 ms。冰箱變了,舊答案不能沿用。花枝丸剩下 3 顆,這時還沒加軟體動物過敏,花枝丸豆腐煮仍然上桌。
  5. 新增 mollusk 過敏:hard profile 升到 v2,key 換成 rk_fc22d099,走 GEMINI。input 2051、output 283、thought 1558,總共 8594 ms。豆腐蛋炒還在;兩爐湯仍是 BURNER_CONCURRENCY_EXCEEDED;花枝丸豆腐煮被 ALLERGEN_GROUP_BLOCKED 擋下。食材名和步驟都寫了花枝丸,所以這個 code 出現兩次。按下這一步時,結果區還多一條黃色對照:如果 key 只用「uid + 今晚吃什麼」,這次會命中 hard v1 的舊結果,把豆腐蛋炒和花枝丸豆腐煮一起端出來。走到第 6 步後,結果區換成外食紀錄,所以上圖看不到這條黃色對照。
  6. 晚餐改外食:走 SHORT_CIRCUIT,482 ms。只在 today_meals 多一筆 dinner:eat_out,花枝丸仍是 3 顆,model_calls = 0。

換成 gemini-3.5-flash、也綁了帳單,三次 GEMINI 仍沒有吃到 Prompt Caching 的折扣。真正把時間和 token 省下來的,是第 2 筆的 CACHE_HIT 和第 5 筆的 SHORT_CIRCUIT。

到 Firebase console 看 requests。這筆是第一次問:input_tokens 2046、cached_input_tokens 0、hard_profile_version 1,key_parts.model 是 gemini-3.5-flash。被擋下的是 two_burner_soup,model_said_ok 是 false,issue 是 BURNER_CONCURRENCY_EXCEEDED。

https://ithelp.ithome.com.tw/upload/images/20261004/20121052wPyrajdJOH.png

response_cache 的文件 id 就是 response key。裡面有三份:rk_279d3e6d、rk_2379137d、rk_fc22d099,對應第一次問、花枝丸少 3 顆、新增過敏。打開第一次那份,key_parts 寫著 hard v1、pantry v1、模型 gemini-3.5-flash,還有 today_hash 和 verifier_version。naive_key 也留著,用來對照「只用 uid 加問題」的那種 key。blocked 裡只有兩爐湯,花枝丸豆腐煮當時還在可上桌的方案裡。

https://ithelp.ithome.com.tw/upload/images/20261004/20121052lVeEzle1nV.png


7. 那金額呢?

requests 只存 token,不存台幣。單價會變,模型也會換;寫死在程式裡,半年後這份紀錄就會說謊。要算錢時,用 token 乘上當天官方價目:

cost = (input - cached) × input 單價
     + cached × cached 單價
     + (output + thought) × output 單價

thought 照 output 計價,這也是第 5 節那張表要看 thought 那一欄的原因。


8. 這和 Eat-Cost Balance 有什麼關係?

Day 0 定義的 Cost 是錢、時間和心力。今天動的是 Cost 裡最容易被忽略的一塊:使用者為了等 AI 回答付出的時間,以及我自己為了跑 AI 付出的帳單。

但 Cost 省下來的前提,是 Eat 那一邊不能被犧牲。過敏是 Eat 裡最硬的一條線,所以 key 寧可多換幾次、多打幾次 Gemini,也不能讓舊的花枝丸豆腐煮混過去。

省多少,看第 6 節那張表:五筆請求裡,CACHE_HIT 和 SHORT_CIRCUIT 兩筆沒打模型,分別是 358 ms 和 482 ms;打 Gemini 的三筆都在 7 到 9 秒。省得對不對,看第 5 步那條黃色的對照。


9. 今日結論

  1. 規則能答就不叫模型。 外食紀錄 model_calls = 0。
  2. response cache 只認版本。 pantry 或 hard profile 一升版,key 自然換掉,不靠 TTL,也不用去刪舊資料。
  3. Prompt Caching 照實記。 顯式 cache 在免費方案 limit=0;隱式快取三次都沒命中,而且每次思考 token 比 output 多出好幾倍。在這個規模,真正省錢的是 Firestore 上那份 exact response cache。
  4. 免費方案每天 20 次。 gemini-3.8-flash 在花枝丸那一步用完額度。後面綁上帳單,預設模型換成 gemini-3.5-flash。

下一篇: 先把 Reality Benchmark 的計時與戰報模板鎖死;Day 21 才真的開火。


附錄 A:哪些變更要升哪個版本

allergens、forbidden_ingredients、飲食型態、equipment、burners 任一變更,升 hard_profile_version。pantry 數量或品項變更,升 pantry_version。today 和 strategies 直接進 hash,不另外記版本。

附錄 B:requests 一筆長這樣

{
  "path": "CACHE_HIT",
  "response_key": "rk_…",
  "hard_profile_version": 1,
  "pantry_version": 1,
  "model_calls": 0,
  "input_tokens": "not_applicable",
  "cached_input_tokens": "not_applicable",
  "output_tokens": "not_applicable",
  "thought_tokens": "not_applicable",
  "model_latency_ms": "not_applicable",
  "response_cache_hit": true,
  "prompt_cache_hit": false
}

上一篇
[Day 18] 「因為比較健康」算證據,還是只是模型很會說?
下一篇
[Day 20] 模型說 15 分鐘,我到底從哪一秒開始算?Alpha 控制台先把計時協定鎖進 Firestore
系列文
DishFlow AI Agent:用 Google AI 打造 Eat-Cost Balance 的下一餐決策系統 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言